Bridge : 앱과 웹, 쉽게 통신하기

React Native에서 WebView를 사용하면 앱 안에서 웹을 띄우는 건 아주 간단합니다.
하지만 앱과 웹이 서로 요청을 주고받는 구조, 즉 양방향 통신이 필요해지면
단순한 메시지 전달만으로는 부족한 순간이 찾아옵니다.

예를 들어:

  • 웹에서 앱의 기능을 호출하거나
  • 앱에서 웹의 상태를 업데이트하거나
  • 파일 다운로드/업로드/공유 기능을 트리거하거나

이런 시나리오에서 서로 요청하고 응답하는 구조가 필요합니다.


기본적인 통신 방법

앱 → 웹: 메시지 보내고 받기

아래는 React Native에서 WebView를 통해 웹과 통신하는 코드입니다.

import React, { useEffect, useRef } from 'react';
import { WebView } from 'react-native-webview';

export default function AppComponent() {
  const webviewRef = useRef<WebView>(null);

  useEffect(() => {
    const message = { type: 'ping' };
    webviewRef.current?.postMessage(JSON.stringify(message)); // 웹에 메시지 전송
  }, []);

  const handleMessage = (event: any) => {
    const message = JSON.parse(event.nativeEvent.data);
    console.log('📨 From Web:', message);
  };

  return (
    <WebView
      ref={webviewRef}
      source={{ uri: 'https://weolbu.com' }}
      onMessage={handleMessage} // 웹에서 오는 메시지 수신
    />
  );
}

아래는 웹에서 이벤트 리스너와 postMessage를 통해 앱과 통신하는 코드입니다.

// React 웹에서
useEffect(() => {
  const message = { type: 'pong' };
  window.ReactNativeWebView?.postMessage(JSON.stringify(message)); // 앱에 메시지 전송
}, []);

useEffect(() => {
  window.addEventListener('message', (event) => {
    console.log('📬 From App:', event.data); // 앱에서 보낸 메시지 수신
  });
}, []);

앱에서는, WebView 객체의 postMessage 를 통해 웹에게 메세지를 전송하고 onMessage를 통해 웹으로부터 메세지를 수신할 수 있습니다. 웹에서는, ReactNative 객체의 postMessage를 통해 앱에게 메세지를 전송하고 이벤트 리스너를 통해 앱으로부터 메세지를 수신할 수 있습니다.




단순 메시지로는 부족한 이유

앞서 우리는 WebViewpostMessageonMessage를 통해 웹과 앱이 간단히 통신할 수 있다는 것을 봤습니다. 하지만 실제 서비스를 만들다 보면 단순한 "한 번 쏘고 끝" 메시지로는 충분하지 않습니다.
예를 들어 웹이 앱에 "현재 버전 알려줘"라고 요청하고, 앱은 그 응답을 돌려줘야 하는 케이스가 생깁니다. 그런 경우엔 아래와 같은 이유로 위에서 설명한 방식대로만으론 처리가 어렵습니다.

1. 요청과 응답을 연결할 수 없다
2. 실행 컨텍스트가 연결되어 있지 않다 - 중간 과정을 기억하기 어렵다
3. 여러 요청이 동시에 발생하면 꼬이기 쉽다

문제 1: 요청과 응답을 연결할 수 없다

앱에서 웹에 버전 정보를 요청했다고 가정해봅시다.

// 앱 → 웹
webviewRef.current?.postMessage(JSON.stringify({ type: 'getVersion' }));

// 웹
window.addEventListener('message', (event) => {
  const { type } = JSON.parse(event.data);
  if (type === 'getVersion') {
    window.ReactNativeWebView.postMessage(JSON.stringify({ version: '1.2.3' }));
  }
});

이렇게 하면 그저 "version"이라는 값이 날아왔을 뿐입니다. "누가 요청했는지" 와 "어떤 요청의 응답인지" 를 알 방법이 없습니다.


문제 2: 실행 컨텍스트가 사라진다

const fetchVersion = () => {
  const handleSaveVersionWithSomeData = () => {
    let 아래에서_생성되는_값;
    // ... 복잡한 로직

    webviewRef.current?.postMessage(JSON.stringify({ type: 'getVersion' }));

    // 응답을 받고 처리하고 싶은데... version을 받을 수가 없네...
    // saveWithSomeData(version, 아래에서_생성되는_값);
  };

  window.addEventListener('message', (event) => {
    const { version } = JSON.parse(event.data);
  });
};

위 코드처럼 이벤트 리스너나 postMessage 호출하는 컨텍스트가 다르기 때문에 한 함수의 흐름에서 로직을 처리하기가 어렵습니다 "내가 보낸 요청" 과 "그에 대한 응답" 을 연결할 방법이 없습니다.


문제 3: 동시에 여러 요청이 들어오면 꼬인다

// 두 요청을 동시에 보냄
webviewRef.current?.postMessage(JSON.stringify({ type: 'getUserInfo' }));
webviewRef.current?.postMessage(JSON.stringify({ type: 'getSettings' }));

// 웹에서는 이런 응답을 줄 수 있음
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getUserInfo', data: { name: 'Tyranno' } }));
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'getSettings', data: { darkMode: true } }));

이런 구조에서는 앱 측에서 어떤 요청의 응답인지, 누구에게 전달해야 하는지 알 수 없습니다. 실수로 응답이 엇갈리면 전혀 다른 데이터가 잘못 처리됩니다.


그래서, 요청에 고유한 ID를 붙이고 응답에는 replyTo: id를 붙여서 요청 → 응답을 정확히 매칭하는 방식이 필요합니다. 그리고 요청을 보낸 시점의 컨텍스트를 기억하는 방식으로 await로 자연스럽게 사용할 수 있는 구조를 만들어야 합니다.

아래에서는 이 방식으로 구현한 Bridge 클래스를 소개합니다.




해결책: ID 기반으로 Promise의 Resolver 저장하기

우리는 이 문제를 다음과 같이 해결했습니다:

  1. 모든 요청에는 고유한 id가 필요
  2. 모든 요청은 Promise를 리턴하고 Map<id, resolver> 형태로 저장한다
  3. 해당 요청에 대한 응답은 replyToid를 되돌려줘야
  4. replyTo에 맞는 resolver를 실행시킨다

이 구조를 활용하면 다음이 가능합니다:

  • Promise 기반으로 await로 메시지를 주고받을 수 있고
  • 동시에 여러 요청이 들어와도 서로 꼬이지 않으며
  • 컨텍스트(콜백)를 유지한 채 명확한 응답을 받을 수 있음

이것을 구현하기 위해 Bridge라는 싱글톤 객체로 만들었습니다.

  • 웹과 앱은 둘 다 메시지를 계속 수신하고 처리할 수 있어야 합니다.
  • 즉, 어느 시점에서든 메시지를 처리해야 하므로 Bridge 인스턴스는 항상 살아 있어야 하며, 중앙집중형 핸들러가 필요합니다. 그래서 싱글톤(Singleton) 패턴을 사용하여, 앱과 웹 양쪽 모두 Bridge 인스턴스를 단일하게 유지합니다.

아래는 조금 길지만 Bridge 구현체 코드입니다.




구현체: App 브릿지 예시

// libs/webview-bridge.ts
import { RefObject } from 'react';
import WebView from 'react-native-webview';

type Handler = (payload?: any) => Promise<any> | any;

class WebviewBridge {
  private webviewRef: null | RefObject<WebView> = null;
  private pendingMap = new Map<string, (data: any) => void>();
  private handlers = new Map<string, Handler>();

  /** WebView ref를 설정합니다. sendMessage 시 필요 */
  setRef = (ref: RefObject<WebView>) => {
    this.webviewRef = ref;
  };

  /** 외부에서 type → handler를 등록합니다 */
  registerHandler = (handlers: Record<string, Handler>) => {
    Object.entries(handlers).forEach(([type, handler]) => {
      this.handlers.set(type, handler);
    });
  };

  /** Web으로 메시지를 보냅니다. 응답은 await로 받을 수 있습니다. */
  sendMessage = <T = any>(type: string, payload?: any, timeout = 3000): Promise<T> => {
    const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
    return new Promise<T>((resolve) => {
      this.pendingMap.set(id, resolve);
      this.webviewRef?.current?.postMessage(JSON.stringify({ type, payload, id }));
      setTimeout(() => {
        if (this.pendingMap.has(id)) {
          this.pendingMap.delete(id);
          resolve(undefined as T);
        }
      }, timeout);
    });
  };

  /**
   * Web에서 들어온 메시지를 처리합니다.
   * - replyTo가 있으면 sendMessage에 대한 응답
   * - type과 id가 있으면 handler 실행
   */
  handleMessage = (event: any) => {
    try {
      const message = JSON.parse(event.nativeEvent.data);
      if (message.replyTo) {
        const resolver = this.pendingMap.get(message.replyTo);
        resolver?.(message.payload);
        this.pendingMap.delete(message.replyTo);
      } else if (message.type && message.id) {
        const handler = this.handlers.get(message.type);
        Promise.resolve(handler?.(message.payload)).then((result) => {
          this.webviewRef?.current?.postMessage(JSON.stringify({ replyTo: message.id, payload: result }));
        });
      }
    } catch (e) {
      console.warn('WebviewBridge handleMessage parse error:', e);
    }
  };
}

export const Bridge = new WebviewBridge();

구현체: Web 브릿지 예시

type Handler = (payload?: any) => Promise<any> | any;

export class WebviewBridge {
  private pendingMap = new Map<string, (data: any) => void>();
  private handlers = new Map<string, Handler>();

  constructor() {
    window.addEventListener('message', this.handleMessage);
  }

  /** 앱 → 웹 메시지를 처리할 핸들러 등록 */
  registerHandlers(handlers: Record<string, Handler>) {
    Object.entries(handlers).forEach(([type, fn]) => {
      this.handlers.set(type, fn);
    });
  }

  /** 앱으로 메시지를 보냅니다. 응답은 Promise로 처리됩니다. */
  sendMessage = <T = any>(type: string, payload?: any, timeout = 3000): Promise<T> => {
    const id = `${type}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
    return new Promise<T>((resolve) => {
      this.pendingMap.set(id, resolve);
      window.ReactNativeWebView?.postMessage(JSON.stringify({ type, payload, id }));
      setTimeout(() => {
        if (this.pendingMap.has(id)) {
          this.pendingMap.delete(id);
          resolve(undefined as T);
        }
      }, timeout);
    });
  };

  /**
   * 앱에서 보낸 메시지를 수신하여 처리합니다.
   * - replyTo 응답이면 pendingMap에 resolve
   * - 요청이면 등록된 handler 실행 후 reply 전송
   */
  private handleMessage = async (event: MessageEvent) => {
    try {
      const msg = JSON.parse(event.data);
      if (msg.replyTo && this.pendingMap.has(msg.replyTo)) {
        this.pendingMap.get(msg.replyTo)?.(msg.payload);
        this.pendingMap.delete(msg.replyTo);
      } else if (msg.type && msg.id && this.handlers.has(msg.type)) {
        const result = await this.handlers.get(msg.type)?.(msg.payload);
        window.ReactNativeWebView?.postMessage(JSON.stringify({ replyTo: msg.id, payload: result }));
      }
    } catch (e) {
      console.warn('[WebBridge] Error parsing message', e);
    }
  };
}

export const Bridge = new WebviewBridge();

개선된 통신 코드 예시

// App 에서 사용되는 코드
const AppComponent = (props, ref) => {
  const webviewRef = useRef<WebView>();

  useEffect(() => {
    Bridge.registerHandler({
      RN_getAppVersion: () => '1.0.0',
    });
  }, []);

  return (
    <WebView
      ref={webviewRef}
      source={{ uri: props.uri }}
      onLoadEnd={props.onWebReady}
      onMessage={Bridge.handleMessage}
      webviewDebuggingEnabled
    />
  );
};

// Web 에서 사용되는 코드
const getAppVersion = async () => {
  const version = await Bridge.sendMessage('RN_getAppVersion');
  console.log('앱 버전:', version);
};

위 예시는 웹 → 앱으로 요청 후 응답을 받는 구조입니다. 그러나 반대도 가능합니다. 웹에서 registerHandlers로 핸들러를 등록해 두었다면, 앱에서도 sendMessage()를 사용하여 동일한 방식으로 async/await로 웹에게 요청을 보내고 응답을 받을 수 있습니다.

마무리

웹과 앱 사이의 통신은 생각보다 까다롭습니다. 단순히 한 쪽에서 메시지를 보내고, 다른 쪽에서 받기만 하는 구조로는 요청과 응답을 정확히 이어붙이기 어렵고, 여러 메시지가 동시에 오가는 상황에서는 더욱 복잡해지기 쉽습니다.

우리가 만든 Bridge 구조는 이러한 문제를 해결하기 위해 만들어졌습니다.

  • 요청마다 고유한 식별자(id)를 부여하고,
  • 해당 요청에 대한 응답을 정확히 찾아서 처리할 수 있게 해주며,
  • 메시지를 보낸 쪽에서는 await를 사용해 마치 함수 하나를 부르는 것처럼 쉽게 결과를 받을 수 있습니다.

이 구조 덕분에 웹과 앱은 정해진 규칙에 따라 서로 요청하고 응답할 수 있고, 어떤 기능이 어디서 호출되든 일관된 흐름으로 로직을 구성할 수 있게 되었습니다.


부록 1. ReactNativeWebview는 어떻게 window 안에 있을까?

window.ReactNativeWebView.postMessage(...)

이 객체는 React Native의 WebView가 주입해주는 전역 객체입니다.

웹 페이지가 WebView 안에서 실행될 때, React Native는 자동으로 window.ReactNativeWebView라는 객체를 만들어두고, 그 안에 메시지를 보낼 수 있는 postMessage() 함수를 넣어줍니다.

즉, 이 객체는 WebView 내부에서만 존재합니다. 로컬 브라우저에서 테스트할 경우 window.ReactNativeWebView는 undefined입니다.


부록 2. message event ?

HTML5에서는 서로 다른 브라우저 창 간의 메시지 통신을 위해 postMessage()와 message 이벤트라는 API가 도입되었습니다.

// 부모 → iframe
iframe.contentWindow.postMessage({ type: 'ping' }, 'https://another.com');

iframe에서는 이렇게 받을 수 있습니다:

window.addEventListener('message', (event) => {
  console.log('📨 받은 메시지:', event.data);
});

예제 코드에서 보듯 크로스-오리진 통신(즉, 서로 다른 도메인 간의 통신 )이 가능합니다. 다만, React Native WebView 환경에서는 window.postMessage()는 의미가 없습니다. 반드시 window.ReactNativeWebView.postMessage()를 사용해야 앱으로 메시지가 전달됩니다.